昨日初步觀察 RISC-V 的指令集架構的一些特徵,如暫存器編排、指令結構設計哲學與主要的指令型態等。今天,我們則要深入介紹 RISC-V 的指令。當一個作者說要逐條介紹 x86 指令的時候,諸位讀者可以預期他接下來就要花個十年半月在單單介紹指令這件事情上面了;但是 RISC-V,為了極簡就是美的哲學,本文就將基本指令集講完吧!順便也讓我們印證一下昨日簡單條列的指令型態部份。
想像中,指令集介紹這種事情簡直像是,以一本字典為讀書會主題而分享的行為;但是筆者必須澄清,其實原本的規格手冊中,介紹了許多設計理念、哲學、定性的初步分析與比較等等,是很有參考價值的文件。但是筆者也知道我們生啃原文文件總是吃力,幸好中國有勇敢的網友果敢地分享知識給大家,有前一個 2.1 版本的規格書翻譯,這裡也附給大家參考。
筆者這裡必須食言,
IMA
的支援有點超過負荷,這裡修正 rvgc 的實作份量為整數基本指令集,日後再求擴充
I
這個部份的指令全部都是 R 型態的,包含以下 10 個指令:
以下就以昨日也出現過的指令位址圖為骨架講解指令的內容。
| 31 25 | 24 20 | 19 15 | 14 12 | 11 07 | 06 00 |
+------------+---------+---------+------------+--------+------------+
| funct7 | rs2 | rs1 | funct3 | rd | opcode |
+------------+---------+---------+------------+--------+------------+
0000000 add 000 0110011
0100000 sub 000 0110011
0000000 sll 001 0110011
0000000 slt 010 0110011
0000000 sltu 011 0110011
0000000 xor 100 0110011
0000000 srl 101 0110011
0100000 sra 101 0110011
0000000 or 110 0110011
0000000 and 111 0110011
0000000 addw 000 0111011
0100000 subw 000 0111011
0000000 sllw 001 0111011
0000000 srlw 101 0111011
0100000 sraw 101 0111011 (64-bit 擴充*)
在這裡,funct3
有 8 種分配方法、funct7
有 2 種。其中,加和減共用一組 funct3
的值、邏輯右移和算術右移也共用一組,所以總共產生十種指令的編排方式。
*:在 64-bit 的暫存器操作中,有時候我們會希望能夠將一個有號 32-bit 整數延伸到整個 64-bit 空間。如果都是 32-bit 的暫存器操作,那麼 2 的補數規則確保了有號、無號整數的算術一致性,但對於延伸到 64-bit 時,就會有上半部全零和全一的差別了。64-bit 擴充操作只適用在加減法以及挪移(shift)指令。
這個階段有 15 個指令:add, sub, and, or, xor, sll, srl, sra, slt, sltu, addw, subw, sllw, srlw, sraw
讀取指令全部都是 I-type,取 rd = rs1[imm]
的意思,然後依照組語指令決定讀取的空間大小,以及正負號擴充與否,總共有 7 個指令
| 31 20 | 19 15 | 14 12 | 11 07 | 06 00 |
+----------------------+---------+------------+--------+------------+
| immediate[11:0] | rs1 | funct3 | rd | opcode |
+----------------------+---------+------------+--------+------------+
lb, load byte 000 0000011
lbu,load byte unsigned 100 0000011
lh, load half 001 0000011
lhu,load half unsigned 101 0000011
lw, load word 010 0000011
lwu,load word unsigned 110 0000011
ld, load dword 011 0000011
分別按照 1、2、4、8 bytes 還有有號無號的分別,優雅地透過 funct3
處理了分歧。
儲存指令則全部都是 S-type,取 rs1[imm] = rs2
的意思。設計時為了讓 rs1 記憶體保持基準位址的結果,就是不得不把整數部份拆掉,改成佔用原本 rd 的部份:
| 31 25 | 24 20 | 19 15 | 14 12 | 11 07 | 06 00 |
+------------+---------+---------+------------+--------+------------+
| imm[11:5] | rs2 | rs1 | funct3 | i[4:0] | opcode |
+------------+---------+---------+------------+--------+------------+
sb, save byte 000 0100011
sh, save half 001 0100011
sw, save word 010 0100011
sd, save dword 011 0100011
儲存指令不像讀取必須顧慮有號無號的問題,就直接寫入特定的內容到記憶體去了。
這個階段有 11 個指令:lb, lh, lw, ld, lbu, lhu, lwu, sb, sh, sw, sd
也就是 rd = rs1 <某種運算> imm
的計算指令。
| 31 20 | 19 15 | 14 12 | 11 07 | 06 00 |
+----------------------+---------+------------+--------+------------+
| immediate[11:0] | rs1 | funct3 | rd | opcode |
+----------------------+---------+------------+--------+------------+
addi 000 0010011
slti 010 0010011
sltiu 011 0010011
xori 100 0010011
ori 110 0010011
andi 111 0010011
addiw 000 0011011
移動指令系列的三個比較特殊,它們稍微特化了數值的區域,使得他們的格式類似這樣:
| 31 26 | 25 20 | 19 15 | 14 12 | 11 07 | 06 00 |
+-----------+----------+---------+------------+--------+------------+
| funct7 | shamt | rs1 | funct3 | rd | opcode |
+-----------+----------+---------+------------+--------+------------+
000000 slli 001 0010011
000000 srli 101 0010011
010000 srai 101 0010011
000000 slliw 001 0011011
000000 srliw 101 0011011
010000 sraiw 101 0011011
其中,字尾有 w 的指令作用在 32-bit 內容,且產生的結果是有號的擴充、第 25 個 bit 的內容一定是 0。
這個階段有 12 個指令:addi, slti, sltiu, xori, ori, andi, slli, srli, srai, slliw, srliw, sraiw
條件指令用的格式是 S-type 的擴充,或稱為 B-type,型態如下
| 31 25 | 24 20 | 19 15 | 14 12 | 11 07 | 06 00 |
+------------+---------+---------+------------+--------+------------+
| i[12|10:5] | rs2 | rs1 | funct3 |[4:1|11]| opcode |
+------------+---------+---------+------------+--------+------------+
beq, branch on equal 000 1100011
bne, branch not equal 001 1100011
blt, branch less than 100 1100011
bgt, branch grater than 101 1100011
bltu, branch less than 110 1100011
bgtu, branch greater than 111 1100011
以 blt 和 bltu 指令為例,當 rs1 < rs2 時,指令中內嵌得很複雜的數字會被加到 pc 暫存器中。平常使用者不能任意修改 pc 的值,而只能透過條件指令與跳躍指令來做流程控制。至於為什麼整數的編碼這麼奇怪?筆者猜測是因為,由於可執行的位址一定要對齊 2 bytes(最短的壓縮指令也有兩個位元組),所以這裡雖然只有 12-bit,但若能省略最低位bit,就可以多擴充一倍的可到達空間,也就是 +/- 4K 的記憶體範圍。
筆者猜測這裡看起來很複雜的 bit 位址分割是因為調整了整數的意義。一般來講,這個 12-bit 的空間可以支援 0~4095 或是 -2048~2047,但是這裡牽涉到指令的跳躍,所以可以多拿一個 bit 來用(讓真正的 bit 0 永遠是 0,也就是永遠是偶數的意思)。然後,因為 bit 31 事關正負號擴充,所以還是必須放置嵌入整數的最高位(imm[12]),但是剩下的線路與 S-type 指令重複使用,所以是 bit 30~25,11~8 的部份線路,然後最後一個 bit 07 已經不需要給真正的 imm[0] 使用,所以就用來放置還沒有去處的 imm[11]。型態上還是類似 S-type,因此被視為是 S-type 的變體。
這裡的指令意義是,如果 rs1 和 rs2 的比較(等於、不等於、小於、大於等於)成立,就跳到 pc + imm
的位址去。
這個階段有 6 個指令:beq, bne, blt, bgt, bltu, bgtu
RISC-V 的非條件跳躍有兩種模式,一種是與 pc 相對差距在 -1MiB(1048576)~+1MiB(1048575) 之內的 jal 指令(可支配 20 bit 整數,代表 imm[20:1]),另外一種是相對差距在 -2048~2047 的 jalr 指令(可支配 12 bit 整數,和讀寫記憶體相同)。jal 指令的調整類似我們剛看過的條件跳躍指令,但是因為嵌入整數的長度不同,所以使用的是修改自 U-type 的 J-type 指令:
| 31 12 | 11 07 | 06 00 |
+---------------------------------------------+--------+------------+
| imm[ 20 | 10:1 | 11 | 19:12] | rd | 1101111 |
+---------------------------------------------+--------+------------+
之所以弄成這樣一副怪德性,應該也可以套用剛才的分析方法:因為最高位 bit 的意義對於判斷整數來說非常重要,所以 imm[20] 還是放置在最前面,然後為了盡量利用原本 I-type 的 11:0 的部份,就是接下來的 imm[10:1] 緊接在後;後面補上 imm[11] 應該是因為這個模式可以和條件跳躍部份的線路重複使用;最後 imm[19:12] 就沒有必要再耍什麼花樣了。jal 指令的行為是將 pc 加上 imm (下一個指令就會是已經跳過去的位址)之前,先將 pc+4 的指令位址存在 rd 中。如果 rd 是 ra,也就是回傳位址的暫存器的話,那麼這個指令就相當於是呼叫,因為在被跳躍位址處回傳時應該可以取得這個回傳位址並跳回;若不是將 rd 設為 ra 暫存器,則就只是一個直接的跳躍。
jalr 指令則是標準的 I-type 指令,意義大致與 jal 類同,只是可支援的範圍只有以 rs1 暫存器為基礎的 -2048~2047。
| 31 20 | 19 15 | 14 12 | 11 07 | 06 00 |
+----------------------+---------+------------+--------+------------+
| immediate[11:0] | rs1 | 000 | rd | 1100111 |
+----------------------+---------+------------+--------+------------+
這個意義是將 rd 設為pc 的值設為原本的 pc+4,然後將 pc 的值設為 rs1+imm 的結果。這個乍看之下不知道可以作什麼,事實上搭配下一段的指令介紹與使用,讀者們就可以了解為什麼這麼限制重重的跳躍模式仍然可以讓程式流程去到(與 pc 相距 32-bit 的)任何地方。
這個階段有 2 個指令:jal, jalr
這是昨日介紹 U-type 時也稍有著墨的 lui 與 auipc 指令。且讓筆者引用昨日所言:
這個指令需要動到
rd
暫存器中高達 20-bit 的內容。有兩個主要的指令使用這個格式,它們是lui
指令,代表將rd
暫存器的[31:12]
取代為指令中的整數;另一個是auipc
,將rd
取代為pc
暫存器加上 imm 整數部份,常用於函數呼叫。
| 31 12 | 11 07 | 06 00 |
+---------------------------------------------+--------+------------+
| immediate[31:12] | rd | opcode |
+---------------------------------------------+--------+------------+
lui, load upper immediate 0110111
auipc, add upper immediate to pc 0010111
所以,與之前小節的指令結合的話,就可以產生以下的幾種使用方法:
為什麼 lui 的狀況比較少見?因為產出 lui 搭配的定址內容之後,很容易成為位址相依的程式碼,實務上使用的量遠遠不及 auipc。但是筆者也注意到 linux kernel 之中一律使用 lui/addi 組合來定位
__vdso_rt_sigreturn
,也許背後還有什麼理由是值得的探討的,但因為不在本系列最感興趣的範圍,因此這裡尚不深究
這個階段有 2 個指令:lui, auipc
這個部份筆者打算只介紹一個指令,那就是系統呼叫使用的 ecall 指令,與其說屬於 I-type,不如說是硬編碼
| 31 20 | 19 15 | 14 12 | 11 07 | 06 00 |
+----------------------+---------+------------+--------+------------+
| 000000000000 | 00000 | 000 | 00000 | 1110011 |
+----------------------+---------+------------+--------+------------+
這個指令會導致系統呼叫的發生,而 Linux 使用的系統呼叫暫存器是 a7,我們之後也許有機會進行相關的範例!
到這裡為止,我們總共介紹了 49 個指令!筆者快不行了...
今日我們介紹 RISC-V 的 I
指令集合,雖然略去了系統相關的部份,也就是這個系列中打算實作在 rvgc 函式庫中的份量,很抱歉稍微有點枯燥,但作為一個參考應該還可以算是可以接受的吧!年關將近,也是要繼續鐵著心發鐵人文,各位讀者,我們明日再見!